Skip to content

fix(storage): cleanup tail in dyn types#3840

Merged
0xrusowsky merged 6 commits intomainfrom
rus/clear-dyn-writes
May 7, 2026
Merged

fix(storage): cleanup tail in dyn types#3840
0xrusowsky merged 6 commits intomainfrom
rus/clear-dyn-writes

Conversation

@0xrusowsky
Copy link
Copy Markdown
Contributor

Motivation

Overwriting a dynamic storable (Vec<T>, String, Bytes) with a shorter value left stale tail slots populated, so subsequent reads observed garbage past the new length.

Solution

Ensure that "shrinking" writes on dyn types clear their stale tails.

Additionally, introduces a new sentinel LayoutCtx::INIT for hot paths that know the destination is virgin (Vec::push), letting them skip the extra SLOAD + cleanup.

@0xrusowsky 0xrusowsky added the cyclops Trigger Cyclops PR audit label May 6, 2026
@0xrusowsky 0xrusowsky force-pushed the rus/clear-dyn-writes branch from 0b5e41b to 33254c0 Compare May 6, 2026 18:03
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 2026

📊 Tempo Precompiles Coverage

precompiles

Coverage: 5639/7749 lines (72.77%)

File details
File Lines Coverage
src/account_keychain/dispatch.rs 30/68 44.12%
src/account_keychain/mod.rs 274/736 37.23%
src/address_registry/dispatch.rs 31/33 93.94%
src/address_registry/mod.rs 50/56 89.29%
src/error.rs 39/114 34.21%
src/ip_validation.rs 10/10 100.00%
src/lib.rs 180/216 83.33%
src/nonce/dispatch.rs 9/10 90.00%
src/nonce/mod.rs 46/61 75.41%
src/signature_verifier/dispatch.rs 19/20 95.00%
src/signature_verifier/mod.rs 13/17 76.47%
src/stablecoin_dex/dispatch.rs 92/93 98.92%
src/stablecoin_dex/mod.rs 867/918 94.44%
src/stablecoin_dex/order.rs 110/161 68.32%
src/stablecoin_dex/orderbook.rs 157/216 72.69%
src/storage/evm.rs 192/221 86.88%
src/storage/hashmap.rs 0/158 0.00%
src/storage/mod.rs 27/27 100.00%
src/storage/packing.rs 68/93 73.12%
src/storage/thread_local.rs 165/227 72.69%
src/storage/types/array.rs 0/72 0.00%
src/storage/types/bytes_like.rs 91/183 49.73%
src/storage/types/mapping.rs 27/48 56.25%
src/storage/types/mod.rs 70/97 72.16%
src/storage/types/primitives.rs 21/24 87.50%
src/storage/types/set.rs 28/192 14.58%
src/storage/types/slot.rs 55/81 67.90%
src/storage/types/vec.rs 103/261 39.46%
src/tip20/dispatch.rs 149/165 90.30%
src/tip20/mod.rs 590/693 85.14%
src/tip20/rewards.rs 238/252 94.44%
src/tip20/roles.rs 107/110 97.27%
src/tip20_factory/dispatch.rs 17/18 94.44%
src/tip20_factory/mod.rs 105/125 84.00%
src/tip403_registry/dispatch.rs 55/56 98.21%
src/tip403_registry/mod.rs 334/371 90.03%
src/tip_fee_manager/amm.rs 286/364 78.57%
src/tip_fee_manager/dispatch.rs 81/83 97.59%
src/tip_fee_manager/mod.rs 71/136 52.21%
src/validator_config/dispatch.rs 38/52 73.08%
src/validator_config/mod.rs 171/227 75.33%
src/validator_config_v2/dispatch.rs 71/73 97.26%
src/validator_config_v2/mod.rs 552/611 90.34%

contracts

Coverage: 1/253 lines (0.40%)

File details
File Lines Coverage
src/lib.rs 1/1 100.00%
src/precompiles/account_keychain.rs 0/40 0.00%
src/precompiles/address_registry.rs 0/12 0.00%
src/precompiles/nonce.rs 0/15 0.00%
src/precompiles/signature_verifier.rs 0/3 0.00%
src/precompiles/stablecoin_dex.rs 0/18 0.00%
src/precompiles/tip20.rs 0/61 0.00%
src/precompiles/tip20_factory.rs 0/9 0.00%
src/precompiles/tip403_registry.rs 0/24 0.00%
src/precompiles/tip_fee_manager.rs 0/18 0.00%
src/precompiles/validator_config.rs 0/13 0.00%
src/precompiles/validator_config_v2.rs 0/39 0.00%

Total: 5640/8002 lines (70.48%)

📦 Download full HTML report

@0xrusowsky 0xrusowsky marked this pull request as ready for review May 6, 2026 18:12
Comment on lines +305 to +310
// Handlers always use `FULL` ctx:
// `T::Handler::write(v)` → `self.as_slot().write(v)` → `Slot::<T>::new(s, a).write(v)`.
// Since the slot we push to is guaranteed empty, we build the `Slot<T>` directly to
// thread `INIT` into `T::store` and skip its tail-cleanup SLOAD for dynamic types.
let elem_slot = self.data_slot() + U256::from(length * T::SLOTS);
Slot::<T>::new_with_ctx(elem_slot, LayoutCtx::INIT, self.address).write(value)?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: i believe this workaround is safe because of the reasons explained in the cmnt

in order to use Self::compute_handler with LayoutCtx::INIT we'd have to change all non-primitive handlers to use and hold ctx (which currently ignore)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just call T::store directly here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in a201aeb (this PR)

@0xrusowsky 0xrusowsky added the S-breaking-stf This PR includes a breaking STF change label May 6, 2026
@jenpaff jenpaff added the T5 label May 6, 2026
Copy link
Copy Markdown

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ Cyclops Review

Four worker passes (w1×3, w2×2) returned NO_FINDING for any T2-active vulnerability. The PR is well-scoped and the new T5-gated cleanup branches plus LayoutCtx::INIT are deterministic, bounded, and consistent with the typed-storage invariants.

Head drift note: workers audited 0b5e41b40efb; this review targets bcc60e304e3fcdad7818f75618fe71019cde6104. The drift moved gating from T4 to T5 and fixed the previously-broken test compile (ONEU256::ONE in structs.rs:309), so that finding has been dropped as stale.

🛡️ [DEFENSE-IN-DEPTH] [T; N] Storable impl not migrated to is_full()

File: crates/precompiles-macros/src/storable_primitives.rs:378,387,399 (not in this PR's diff — body-only)

Sister types (Vec, String, Bytes, struct macro) were uniformly updated to debug_assert!(ctx.is_full(), …), but gen_array_impl's generated handle/load/store still use debug_assert_eq!(ctx, LayoutCtx::FULL, …). Today this is dormant — no production Vec<[T; N]> exists and the struct macro forces FULL on non-dynamic fields. But if anyone adds Vec<[u8; 32]> (or any [T; N] with T::BYTES * N > 16), Vec::push will thread INIT into [T; N]::store and the equality assert will fire in debug/test builds (release silently works because store_impl doesn't consult ctx).

Recommended Fix: Replace the three debug_assert_eq!(ctx, LayoutCtx::FULL, …) calls with debug_assert!(ctx.is_full(), …) for symmetry.

See inline comments for the other two items.

Reviewer Callouts
  • Gas-cost change for shrinking dynamic types under T5Vec::store and store_bytes_like add up to prev - new extra SSTOREs (or T::delete calls for unpacked) on T5. Gas-metered and not griefable, but per-operation costs of shrinking change post-T5. Worth confirming downstream precompile gas budgets (e.g., account_keychain revoke flows, stablecoin_dex book mutations, validator config rewrites with long string fields) accommodate the additional SSTOREs.

  • LayoutCtx::INIT "virgin by construction" for Vec::push is fork-history sensitive — Holds for any Vec whose entire lifetime is on T5 (every shrinking path zeros freed elements). Does NOT hold for a Vec whose history includes a pre-T5 shrink. Concretely, write(vec![long_a, long_b, long_c]) then write(vec![x, y]) under T2/T3/T4 leaves long_c's chunks live; a later T5 push("z") to index 2 writes the base slot with INIT but the legacy chunks at keccak256(data_start + 2) + i persist. Impact is purely storage bloat — every read path is length-bounded and keccak256 precludes collisions with live storage.

  • Pre-T5 → T5 activation hygiene — Combined with the bytes_like inline finding, a human reviewer should confirm the T5 activation plan accounts for legacy dynamic tails that may already exist when T5 activates. Cleanup only runs on subsequent writes/deletes; pre-existing ghost data in long validator config strings, TIP-20 metadata, etc. is never proactively scrubbed.

  • Mapping::IS_DYNAMIC defaults to false (storage/types/mod.rs:182) — Mapping is keccak-addressed but does not implement Storable (only StorableType), so the derive splits mapping fields out before calling store/load/delete. Harmless today, but if a future change adds a Storable impl to Mapping, the propagation logic in gen_store_impl would need updating.

Comment thread crates/precompiles/src/storage/types/bytes_like.rs
Comment thread crates/precompiles/src/storage/types/vec.rs
decofe pushed a commit that referenced this pull request May 6, 2026
Collects bug fixes and infrastructure changes gated behind T5:
1. Fix fixed-size array packing in precompile storage codegen (#3811)
2. Clean up stale tail slots in dynamic storage types (#3840)
3. Deploy ERC-2470 and NanoUniversalDeployer at T5 boundary (#3742)

Standalone feature TIPs (1026, 1030, 1033, 1035, 1047, 1056) are
excluded — they already have their own specs.

Amp-Thread-ID: https://ampcode.com/threads/T-019dfe69-9662-77ff-9ff0-390655ec07ff
decofe pushed a commit that referenced this pull request May 6, 2026
Collects bug fixes gated behind T5:
1. Fix fixed-size array packing in precompile storage codegen (#3811)
2. Clean up stale tail slots in dynamic storage types (#3840)

Standalone feature TIPs (1026, 1030, 1033, 1035, 1047, 1056) are
excluded — they already have their own specs.

Amp-Thread-ID: https://ampcode.com/threads/T-019dfe69-9662-77ff-9ff0-390655ec07ff
Comment on lines +415 to +420
// Vec elements can't be split across slots.
let from_slots = calc_packed_slot_count(from, T::BYTES);
let to_slots = calc_packed_slot_count(to, T::BYTES);
for slot_idx in from_slots..to_slots {
storage.store(data_start + U256::from(slot_idx), U256::ZERO)?;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like if from_slots == to_slots this won't clear anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, and i think that's correct (i should probably document it better).

my reasoning is as follows:

  • delete always uses clear_elements(.., from=0, to=length), which means it always deletes smth
  • tail cleanup doesn't delete the same element cause it is overriden by the later SSTORE, so it would be redundant

Comment thread crates/precompiles-macros/src/storable.rs Outdated
@0xrusowsky 0xrusowsky enabled auto-merge May 7, 2026 15:22
@0xrusowsky 0xrusowsky added this pull request to the merge queue May 7, 2026
Merged via the queue into main with commit b05f050 May 7, 2026
32 checks passed
@0xrusowsky 0xrusowsky deleted the rus/clear-dyn-writes branch May 7, 2026 15:56
Thegreatsura pushed a commit to Thegreatsura/tempo that referenced this pull request May 8, 2026
…t_array_impl (tempoxyz#3860)

Follow-up to tempoxyz#3840 — the five `debug_assert_eq!(ctx, LayoutCtx::FULL)`
checks in `gen_array_impl` and `gen_struct_array_impl`
(`storable_primitives.rs`) were not updated when the `LayoutCtx::INIT`
sentinel was introduced.

This switches them to `debug_assert!(ctx.is_full())` so passing `INIT`
no longer trips the assertion in debug builds.

- 3 assertions in `gen_array_impl` (handle / load / store)
- 2 assertions in `gen_struct_array_impl` (load / store)

All 89 storage tests pass.

Co-authored-by: Centaur AI <ai@centaur.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cyclops Trigger Cyclops PR audit S-breaking-stf This PR includes a breaking STF change T5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants